Skip to main content

Return-Oriented Programming Advanced

In this session we are going to dive deeper into Return-Oriented Programming and setbacks that appear in modern exploitation. Topics covered:

  • ROP for syscalls and 64 bits
  • Dealing with ASLR in ROP
  • Dealing with low space in the overflown buffer
  • Combining ROP and shellcodes

As the basis of the lab we will use a program based on a classical CTF challenge called ropasaurusrex and gradually make exploitation harder.

Calling Conventions in the ROP Context

As you know, the calling convention for 32 bits uses the stack. This means that setting up parameters is as easy as just writing them in the payload.

We can see how a function call is generated in this Compiler Explorer example.

Syscalls are special, the arguments are passed using the registers and int 0x80 or the equivalent call DWORD PTR gs:0x10 is used such that more work is needed: pop ?; ret gadgets are needed to load the registers with the desired values.

In the assembly below you see a disassembly of the calling of a system call read(0, 0x8048000, 0x100), with the system call in the eax register and the system call arguments in the other registers:

mov eax, 0x3
mov ebx, 0
mov ecx, 0x08048000
mov edx, 0x100
int 0x80

The calling convention for 64 bit processors (x86_64) is different and mainly uses registers instead of the stack, see this Compiler Explorer example.

Syscalls on 64 bits are conceptually the same as on 32 bits, but it uses different registers, different syscall codes and the syscall mnemonic is used for making a system call:

mov rax, 0
mov rdi, 0
mov rsi, 0x08048000
mov rdx, 0x100
syscall

ROP gadgets on x86_64

On x86_64 the ROP payloads will have to be built differently than on x86 because of the different calling convention. Having the function arguments stored in registers means that you don't need to do stack cleanup anymore, but you will need gadgets with specific registers to pop the arguments into.

For example to do the read(0, buf, size) libc call to do this call your payload will need to look like:

pop rdi; ret
0
pop rsi, ret
buf_addr
pop rdx; ret
size
call read@plt

Libc leaks

You might have already encountered in other tasks the need to leak values or addresses. Most of the time, if you want to get a shell, you won't have a convenient system@plt symbol present in your binary, and ASLR will most often be activated; so you will have to compute it relative to another libc symbol at runtime.

For this we will need to know what libc library the program is loading. For a local executable we can just run ldd:

$ ldd rop
linux-vdso.so.1 (0x00007ffd0834b000)
libc.so.6 => /usr/lib/libc.so.6 (0x00007fec18eb6000)
/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007fec190aa000)

For remote tasks you can might get an attached libc.so, or you can use the Libc database to find the correct libc based on some leaked offsets.

How to compute and use the system function address using pwntools:

from pwn import *

libc = ELF("/usr/lib/libc.so.6") # from `ldd rop`
p = process('rop')

...
# read the leaked address of the write@got function from the program
write_leak = u64(p.recv(8))
# compute the starting address of the libc library
# setting libc.address to this value will offset all future symbol accesses
libc.address = write_leak - libc.symbols['write']

# use the address of system in the payload
payload = ... + p64(libc.symbols['system'])

Challenges

Note: All tasks from this session are 64 bit binaries, so take that into consideration when you build the ROP chains.

01. Challenge - Using ROP to Leak and Call system

Use the 01-leak-call-system/src executable file in order to spawn a shell.

You can now call the functions in the binary but system or any other appropriate function is missing and ASLR is enabled. How do you get past this? You need an information leak! To leak information we want to print it to standard output and process it. We use calls to printf, puts or write for this. In our case we can use the write function call.

If you have a string representation of a number you can unpack it using the unpack/u64 function in pwntools. It is the reverse of the pack/p64 function.

First, trigger the information leak by calling the write function and leaking an address from libc.

You can use the GOT table storing libc addresses.

You need to read the output from the above write call. Use p.recv(8) in the Python script to read the 8 bytes output of the write call in the ROP chain.

Remember that you need gadgets to pop values into rdi, rsi, rdx for the write call.

Find the address of the system call.

Remember the libc leaks section above

Call `system().

You can't write the system address in the ROP chain as it is different each time and the ROP chain is statically defined. You can use the GOT table again. Write an entry in the GOT table with the newly found address and call the function for that entry. It will evolve into a call to system.

To write an entry in the GOT table use the read call in the ROP chain. You will feed to read the computed address below.

For the actual parameter use the "sh" string already present in the vulnerable binary. Use searchmem in GDB to find the "sh" string in the executable.

02. Challenge - Handling Low Stack Space

The previous binary had the luxury of plenty of stack space to be overflown. It is often the case that we don't have enough space for a long ROP chain. Let's handle that.

For the current task, switch to the 02-low-stack-space/src sub-folder. The extra constraint here is that huge ROP chains are no longer an option.

Find out how much space you have in the overflow and assess the situation.

Use gdb and the cyclic pattern to get the information required.

Now follow the steps below.

First trigger the info leak as before.

Use write and leak the address of a GOT value. Use this to compute the address of the system call.

You can only construct a partial ROP chain. A longer one won't fit. So after calling write, call main again.

Note that using sendline means sending out a newline character ('\n') at the end of the message. If you want to strictly send out a message without a newline, use send.

Find the address of main by looking at the argument for the __libc_start_main function. Check the disassembling of the program and see what is the parameter passed to the __libc_start_main call.

After calling main again you will get back to the initial situation where you can exploit the buffer overflow.

Insert "sh" string.

This time you don't have the "sh" string in the binary, but you can find it in the libc binary itself so you can compute it the same way you compute the system address. In pwntools:


sh = next(libc.search(b"/bin/sh\x00"))

Call system.

03. Challenge - Stack Pivoting

Let's assume that main function had additional constraints that made it impossible to repeat the overflow. How can we still solve it? The method is called stack pivoting. In short, this means making the stack pointer refer another (writable) memory area that has enough space, a memory area that we will populate with the actual ROP chain.

Read more about stack pivoting here.

Tour goal is to fill the actual ROP chain to a large enough memory area. We need a two stage exploit:

  • In the first stage, prepare the memory area where to fill the second stage ROP chain; then fill the memory area with the second stage ROP chain.
  • In the second stage, create the actual ROP chain and feed it to the program and profit.

Follow the steps below.

Use pmap or vmmap in pwndbg to discover the writable data section of the process. Select an address in that section (don't use the start address). This is where you fill the 2nd stage data (the actual ROP chain).

Who not use the start address? Because pop instructions (which decrease the rsp) will go outside the memory region.

Create a first stage payload that calls read to store the 2nd stage data to the newly found memory area. After that pivot the stack pointer to the memory area address.

At a given address in the executable you have a call to read followed by a leave; ret gadget. This sequence of instructions allows you to read data and then pivot the stack.

The leave instruction fills the stack pointer (rsp) with the address of the frame pointer (rbp). It's equivalent to:

mov rsp, rbp
pop rbp

Write the actual ROP chain as a second stage payload like when we didn't have space constraints. The 2nd stage will be stored to the memory area and the stack pointer will point to that.

Important! Be careful when and where the stack pivoting takes place. After the mov rsp, rbp part of the leave instruction happens your stack will be pivoted, so the following pop rbp will happen on the new stack. Take this offset into account when building the payload.

04. Challenge - mprotect

Combine everything you've learned until now and develop a complex payload to call mprotect to change the permissions on a memory region to read+write+execute and then insert a shellcode to call system("/bin/sh").

Further Reading